微服务越来越多,如何打通各个服务之间的登录状态?来试试JWT

2023-06-28 16:56:40 342 林溪
  • 我们知道,http是无状态的,即每次http请求对于服务器来说都是新用户,那么我们怎么解决登录的问题呢?

cookie

  • 最早我们可以使用cookie,即一个用户第一次请求的时候,是没有cookie的,这个时候呢,服务器会自动生成一个session_id存放到cookie里面去,也就是说浏客户端的cookie,服务器不仅可以读,而且可以写,正是基于此我们就可以实现用户登录的功能。比如我们首次访问https://www.acurd.com
  • 我们第二次再访问

go操作session

go并不像php那样原生就支持session,我们可以根据自己的需求实现一个session库,也可以使用第三方的库,这也再次说明了session并不是什么神秘的技术,而是基于服务端对客户端cookie的读写来实现的。 这里我们使用第三方库来演示一下

package main

import (
 "fmt"
 "github.com/gorilla/mux"
 "github.com/gorilla/sessions"
 "log"
 "net/http"
)

var store = sessions.NewCookieStore([]byte("secret-key"))

// 自定义一个账户和密码
var name, pwd = "acurd", "acurdpwd"

func LoginHandler(w http.ResponseWriter, r *http.Request) {

 //获取session
 session, _ := store.Get(r, "session_id")

 //打印session信息
 fmt.Printf("%+v", session.Values)
 if !session.IsNew {
  fmt.Fprintf(w, "<h1>你已经登录了</h1>")
  return
 }

 r.ParseForm()       // 解析参数,默认是不会解析的
 fmt.Println(r.Form) // 这些信息是输出到服务器端的打印信息
 //有的话校验登录
 username := r.FormValue("username")
 password := r.FormValue("password")
 if username == name && password == pwd {
  session.Values["username"] = username
  // 将session保存
  err := session.Save(r, w)
  if err != nil {
   http.Error(w, err.Error(), http.StatusInternalServerError)
   return
  }
  fmt.Fprintf(w, "<h1>登录成功</h1>")

  return
 }
 //没有的话提示输入用户名密码
 fmt.Fprintf(w, "<h1>用户名或密码失败</h1>")
}

func main() {
 // 创建路由
 r := mux.NewRouter()
 r.HandleFunc("/login", LoginHandler)
 err := http.ListenAndServe(":8002", r)
 if err != nil {
  log.Fatal("ListenAndServe: ", err)
 }
}

缺点

cookie+session这种方式的缺点

  • 依赖cookie实现,所以客户端不能禁用cookie
  • cookie可能被截获,别人利用我们的cookie信息就可以跳过登录验证
  • session是如果是存储在服务器内存的话,当我们跨服务器访问的时候,不能识别登录状态
  • 如果用户量很大的话,session占用的服务器内存也就越大。

Token

  • 基于Token的认证是近几年非常流行的认证方式。基于token的认证中,最常用的是JSON Web Tokens,即JWT(JSON Web Token的缩写),这种方式摒弃了传统的基于cookie和session的登录认证,可以实现跨域认证。

JWT

jwt登录的流程

  • 第一次请求使用 用户名+密码完成登录
  • 登录成功颁发token,token里面可以有你自定义的一些信息,比如用户名,用户id,会被加密后返回给前端
  • 前端保留此token,每次请求的时候带上该token,后端解析后就可以读取token里的用户名和用户id
  • 只要服务端使用的是同一个秘钥,token都会被解析成功,也就是说解决了跨域登录的问题。

jwt的数据结构

我们先来看一个token长什么样子,比如这个eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VybmFtZSI6ImFjdXJkIiwiVWlkIjoxMiwic3ViIjoiVG9rZW4iLCJleHAiOjE2ODc4Njk2NjYsImlhdCI6MTY4Nzg2NjA2Nn0._hIRJDrLV8co1gZPKKxG2AyMVFRapl-nt-Kqbb-r8bg,我们看到这个token被.分割成了三部分,那么这三部分代表什么含义呢?我们先来还原一下这三部分怎么来的

  • 第一部分eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9是对算法和type的加密,加密算法是base64.urlencode,相关代码如下:
func TestDemoTwo28(t *testing.T) {

 type JWTHeader struct {
  Alg string `json:"alg"`
  Typ string `json:"typ"`
 }
 header := JWTHeader{
  Alg: "HS256",
  Typ: "JWT",
 }
 headerBytes, _ := json.Marshal(header)
 headerBase64 := base64.RawURLEncoding.EncodeToString(headerBytes) // 对字节数组进行 Base64 编码
 fmt.Println(headerBase64)                                         // 输出编码后的 JWT Header

}

执行结果是一模一样的

  • 第二部分eyJVc2VybmFtZSI6ImFjdXJkIiwiVWlkIjoxMiwic3ViIjoiVG9rZW4iLCJleHAiOjE2ODc4Njk2NjYsImlhdCI6MTY4Nzg2NjA2Nn0其实也是base64.urlencode加密得来的,加密的对象就是我们存储的数据,咱们可以用解码的方式解析一下看看

相关代码如下

func TestDemoTwo28(t *testing.T) {
 str := "eyJVc2VybmFtZSI6ImFjdXJkIiwiVWlkIjoxMiwic3ViIjoiVG9rZW4iLCJleHAiOjE2ODc4Njk2NjYsImlhdCI6MTY4Nzg2NjA2Nn0"
 decodeString, _ := base64.RawURLEncoding.DecodeString(str)
 fmt.Printf("%s", string(decodeString))
}

执行结果如下

通过第一部分和第二部分我们看到了,这是一种可逆的加密,所以不要放重要数据(也可以再次对payload的数据对称加密,保证数据不外泄),比如登录密码,手机号。接下来看第三部分。这里我贴一份可逆加密的代码

package main

import (
 "crypto/aes"
 "crypto/cipher"
 "encoding/base64"
 "encoding/hex"
 "fmt"
)

func main() {
 // 原始数据
 source := "Hello, world!"
 fmt.Println("原文:", source)

 // 密钥,必须是 16、24 或 32 字节
 key := "example key 1234"

 // 加密
 encryptCode := AESEncrypt([]byte(source), []byte(key))
 fmt.Println("密文(byte):", encryptCode)

 // 使用 hex 编码打印
 fmt.Println("密文(hex):", hex.EncodeToString(encryptCode))

 // 使用 base64 编码打印
 fmt.Println("密文(base64):", base64.StdEncoding.EncodeToString(encryptCode))

 // 解密
 decryptCode := AESDecrypt(encryptCode, []byte(key))
 fmt.Println("解密后的原文:", string(decryptCode))
}

// AESEncrypt 使用 AES 加密数据
func AESEncrypt(data, key []byte) []byte {
 block, err := aes.NewCipher(key)
 if err != nil {
  panic(err)
 }
 ciphertext := make([]byte, aes.BlockSize+len(data))
 iv := ciphertext[:aes.BlockSize]
 stream := cipher.NewCFBEncrypter(block, iv)
 stream.XORKeyStream(ciphertext[aes.BlockSize:], data)
 return ciphertext
}

// AESDecrypt 使用 AES 解密数据
func AESDecrypt(data, key []byte) []byte {
 block, err := aes.NewCipher(key)
 if err != nil {
  panic(err)
 }
 if len(data) < aes.BlockSize {
  panic("ciphertext too short")
 }
 iv := data[:aes.BlockSize]
 data = data[aes.BlockSize:]
 stream := cipher.NewCFBDecrypter(block, iv)
 stream.XORKeyStream(data, data)
 return data
}
  • 第三部分_hIRJDrLV8co1gZPKKxG2AyMVFRapl-nt-Kqbb-r8bg 这个数值是怎么来的呢?就是基于前两段的值+秘钥计算得来,具体算法如下HMACSHA256( base64UrlEncode(header) + "." +base64UrlEncode(payload), secret ),这一部分才是校验我们数据的准确性,比如数据是否早到篡改。

我们可以通过https://jwt.io/更直观的观察结果 这个时候,是不是上来就是一句卧槽,原来token是可以被解析的,那我的token岂不是不安全,说的对,token只是实现登录认证,并不保证你的数据不外泄,所以我们不能把重要的信息放到token里面去。但是如果你篡改了数据,伪造登录,由于别人没有你的秘钥,导致第三段校验失败,就可以拦截非法登录了。

其实了解了jwt的实现机制,我们自己也可以定义一套自己的token机制来实现登录功能。 下面我们来看一下别人已经造好的轮子是怎么用的。

使用jwt实现登录功能

我们使用go的第三方包来实现jwt登录功能

怎么找包

怎么找包要认真说,还真是一个技术活,你要是网上随便搜一个,那么第一安全性,可靠性都可能会有问题,更别谈后期的维护了。那么一般我们怎么找包呢?github上面通过 语言+关键词来搜索,一般使用star最多的就行了,比如我们在github 搜 go jwt 我们看到第一个和第二个其实是一个仓库,所以我们使用github.com/golang-jwt/jwt/v5这个包

  • 下面是jwt登录功能的相关代码
package main

import (
 "fmt"
 "github.com/golang-jwt/jwt/v5"
 "github.com/gorilla/mux"
 "log"
 "net/http"
 "time"
)

// 定义自己的秘钥 所有的服务必须用一个秘钥才能正确解析token
var privateKey = []byte("my_secret_key")

// 自定义一个账户和密码 用户uid 这里简单举个例子,一般是去数据库校验
var name, pwd, uid = "acurd", "acurdpwd", 12

// UserClaims 我们声明一个结构体,里面包含我们想要保存的信息
type UserClaims struct {
 Username             string
 Uid                  int64
 jwt.RegisteredClaims // 内嵌标准的声明
}

// GenToken 生成token
func GenToken(username string, uid int64) (string, error) {
 //初始化结构体
 claims := UserClaims{
  Uid:      uid,
  Username: username,
  RegisteredClaims: jwt.RegisteredClaims{
   //设置过期时间
   ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 3600)),
   //颁发时间
   IssuedAt: jwt.NewNumericDate(time.Now()),
   //主题
   Subject: "Token",
  },
 }
 //生成token  使用hs256 加密 结构体,然后再用秘钥对其做数字签名
 return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(privateKey)
}

// 解析token
func parseToken(tokenString string) (*UserClaims, error) {
 claims := new(UserClaims)
 token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
 //建议token是否有效
 if token.Valid {
  return claims, nil
 }
 return nil, err
}
func keyFunc(token *jwt.Token) (interface{}, error) {
 return privateKey, nil
}

func LoginHandler(w http.ResponseWriter, r *http.Request) {

 //限制post提交
 if r.Method != "POST" {
  fmt.Fprintf(w, "<h1>非法登录</h1>")
  return
 }

 //获取token
 auth := r.Header.Get("Authorization")
 if len(auth) > 0 { //刺入还可以加入对
  //打印token信息
  fmt.Println(auth)
  //校验token
  claims, err := parseToken(auth)
  if err != nil {
   fmt.Fprintf(w, "<h1>解析token失败</h1>")
   return
  }
  //
  fmt.Fprintf(w, "<h1>您已经登录了</h1>相关token解析如下:%+v", claims)
  return
 }

 r.ParseForm()       // 解析参数,默认是不会解析的
 fmt.Println(r.Form) // 这些信息是输出到服务器端的打印信息
 //有的话校验登录
 username := r.FormValue("username")
 password := r.FormValue("password")
 if username == name && password == pwd {
  // 生成token并返回
  token, err := GenToken(username, int64(uid))
  if err != nil {
   fmt.Fprintf(w, "<h1>生成token失败</h1>")
  }
  // 将token保存
  fmt.Fprintf(w, "<h1>登录成功</h1>请保存好你的token:%s", token)
  return
 }
 //没有的话提示输入用户名密码
 fmt.Fprintf(w, "<h1>用户名或密码失败</h1>")
}

func main() {
 // 创建路由
 r := mux.NewRouter()
 r.HandleFunc("/login", LoginHandler)
 err := http.ListenAndServe(":8002", r)
 if err != nil {
  log.Fatal("ListenAndServe: ", err)
 }
}

  • 我们运行一下上面的代码 第一次获取到token
  • 第二次带token请求
  • 如果修改里面的任意一个字符

jwt的刷新机制

场景1:我们用QQ或者微信,不可能每打开一次都执行一次登录操作,而是好长时间不用,比如一个月没有打开QQ,那么你再次打开QQ,可能就需要登录了,但是如果你经常打开QQ,可能好几个月都不需要登录,那么这是怎么实现的呢?

场景2:你设计了一套jwt的登录系统,token有效期是60分钟,用户在58分钟的时候打开了网页,在61分钟的时候点击提交,发现自己被退出登录了,你说用户气不气?

refresh_token

基于上面的两种场景,我们提出来refresh_token的机制,其实就是多了一个刷新token的触发机制,第一次登录的时候,我们给用户颁发两个token ,一个access_token,有效期比较短,比如是4小时,还有一个refresh_token ,就是刷新token,假设有效期是24小时。

  • 第一次登录,我们返回access_token和refresh_token
  • 后面的请求,我们校验access_token是否过期,没过期的放行通过,过期的话就要校验refresh_token是否过期,如果没有过期,则拿着refresh_token 下发新的access_token和新的refresh_token(下发新的refresh_token是为了避免活跃用户的频繁登录,比如24小时内只要用户登录过,就不会弹出登录窗口。具体根据业务看)。如果两个token都过期了,则重新登录。
  • 相关代码如下
package main

import (
 "fmt"
 "github.com/golang-jwt/jwt/v5"
 "github.com/gorilla/mux"
 "log"
 "net/http"
 "time"
)

// 定义自己的秘钥 所有的服务必须用一个秘钥才能正确解析token
var privateKey = []byte("my_secret_key")

// 自定义一个账户和密码 用户uid 这里简单举个例子,一般是去数据库校验
var name, pwd, uid = "acurd", "acurdpwd", 12

// UserClaims 我们声明一个结构体,里面包含我们想要保存的信息
type UserClaims struct {
 Username             string
 Uid                  int64
 jwt.RegisteredClaims // 内嵌标准的声明
}

// RefreshClaims 用来生成refresh token
type RefreshClaims struct {
 jwt.RegisteredClaims // 内嵌标准的声明
}

// GenToken 生成token
func GenToken(username string, uid int64) (string, error) {
 //初始化结构体
 claims := UserClaims{
  Uid:      uid,
  Username: username,
  RegisteredClaims: jwt.RegisteredClaims{
   //设置过期时间
   ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 30)),
   //颁发时间
   IssuedAt: jwt.NewNumericDate(time.Now()),
   //主题
   Subject: "Token",
  },
 }
 //生成token  使用hs256 加密 结构体,然后再用秘钥对其做数字签名
 return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(privateKey)
}

// 解析token
func parseToken(tokenString string) (*UserClaims, error) {
 claims := new(UserClaims)
 _, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
 return claims, err
}
func keyFunc(token *jwt.Token) (interface{}, error) {
 return privateKey, nil
}

// GenRefreshToken 生成token
func GenRefreshToken() (string, error) {
 //初始化结构体
 claims := UserClaims{
  RegisteredClaims: jwt.RegisteredClaims{
   //设置过期时间
   ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 3600)),
   //颁发时间
   IssuedAt: jwt.NewNumericDate(time.Now()),
   //主题
   Subject: "RefreshToken",
  },
 }
 //生成token  使用hs256 加密 结构体,然后再用秘钥对其做数字签名
 return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(privateKey)
}

// 解析token
func parseRefreshToken(tokenString string) (*RefreshClaims, error) {
 claims := new(RefreshClaims)
 token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
 //建议token是否有效
 if token.Valid {
  return claims, nil
 }
 return nil, err
}

func LoginHandler(w http.ResponseWriter, r *http.Request) {

 //限制post提交
 if r.Method != "POST" {
  fmt.Fprintf(w, "<h1>非法登录</h1>")
  return
 }

 //获取token
 auth := r.Header.Get("Authorization")
 tokenRefresh := r.Header.Get("AuthorizationRef")
 fmt.Println(auth)
 fmt.Println(tokenRefresh)
 if len(auth) > 0 {
  // 解析refresh token
  claimsR, err := parseRefreshToken(tokenRefresh)
  if err != nil {
   fmt.Fprintf(w, "token 错误或过期%s", err)
   return
  }
  fmt.Printf("claimsR %v", claimsR)
  //解析token
  claims, err := parseToken(auth)
  fmt.Printf("claims %+v  err:%v", claims, err)
  //token未过期
  if claims != nil && !claims.Expired() {
   fmt.Fprintf(w, "<h1>您已经登录了</h1>相关token解析如下:%+v", claims)
   return
  }
  token, _ := GenToken(claims.Username, int64(uid))
  refreshToken, _ := GenRefreshToken()
  // 将token保存
  fmt.Fprintf(w, "<h1>已经更新token</h1>请保存好你的token:%s;refreshToken:%s", token, refreshToken)
  return
 }

 r.ParseForm()       // 解析参数,默认是不会解析的
 fmt.Println(r.Form) // 这些信息是输出到服务器端的打印信息
 //有的话校验登录
 username := r.FormValue("username")
 password := r.FormValue("password")
 if username == name && password == pwd {
  // 生成token并返回
  token, _ := GenToken(username, int64(uid))
  refreshToken, _ := GenRefreshToken()
  // 将token保存
  fmt.Fprintf(w, "<h1>登录成功</h1>请保存好你的token:%s;refreshToken:%s", token, refreshToken)
  return
 }
 //没有的话提示输入用户名密码
 fmt.Fprintf(w, "<h1>用户名或密码失败</h1>")
}

// Expired 检查自定义的token结构体是否过期
func (token *UserClaims) Expired() bool {

 fmt.Println(time.Now().Unix())
 fmt.Println(token.ExpiresAt.Unix())
 return time.Now().Unix() > token.ExpiresAt.Unix()
}

func main() {
 // 创建路由
 r := mux.NewRouter()
 r.HandleFunc("/login", LoginHandler)
 err := http.ListenAndServe(":8002", r)
 if err != nil {
  log.Fatal("ListenAndServe: ", err)
 }
}

在这里插入图片描述

jwt的缺点

由于jwt是无状态的,而且token是在登录时生成的,所以会有以下两个问题

  • 数据更新的问题,如果用户信息发生变更,但是token解析的还是老数据
  • token一旦签发无法收回和废弃。